4장. 검색을 똑똑하게 만드는 법

출처: 『RAG 마스터: 랭체인으로 완성하는 LLM 서비스』(브라이스 유·조경아·박수진·김재웅 지음, 프리렉 2025) | 공식: www.langchain.com

코드는 분위기만 — from_documents·weights·predict 같은 말은 몰라도 됩니다. 표의 '비유'와 '위험'만 봐도 충분해요.

기본 RAG는 한 줄로 흐른다.

질문을 그대로 넣고, 비슷한 조각을 꺼내, 그걸 보고 답한다.

그런데 그 한 줄의 각 칸이 허술하면 답도 허술하다.

이 장은 그 칸들을 하나씩 손봐서 검색을 더 똑똑하게 만드는 법을 다룬다.

천천히 따라오면 된다.


0. 이 장의 새 단어 (0장에 없는 말만 3개)

이 셋만 미리 알아 두면 본문이 술술 읽힌다.

나머지 어려운 말은 0장 용어집에 다 있다.


부모-자식 분할(parent-child chunking)

한 문장 뜻 — 문서를 큰 조각(부모)작은 조각(자식) 두 단으로 잘라, 작은 걸로 찾고 큰 걸로 답하는 방식.

일상비유 — 책을 찾을 때 색인의 한 줄(작은 단서)로 위치를 짚고, 정작 읽는 건 그 줄이 속한 한 페이지 전체(넓은 맥락)인 것.

한 줄 예 —

# 아래 예제는 핵심 흐름만 짧게 보여 줍니다.
# 작은 자식으로 찾고, 큰 부모를 돌려준다
retriever.invoke("투자 종류가 뭐야?")  # → 부모 조각(넓은 맥락) 반환

리랭킹(reranking)

한 문장 뜻 — 일단 빠르게 꺼내 온 문서 후보를, 더 꼼꼼한 채점기로 다시 줄 세우는 마무리 손질.

일상비유 — 서류 1차로 100장 추리고, 2차에서 한 장씩 정독해 진짜 알짜만 남기는 것.

한 줄 예 —

# 빠르게 4개 꺼내고 → 꼼꼼히 채점해 2개만 남김
# 검색·답변 품질을 점수나 기준으로 비교합니다.
reranked = rerank(query, first_4_docs)[:2]

Self-RAG(자기 점검 RAG)

한 문장 뜻 — AI가 검색이 필요한지, 꺼낸 문서가 쓸 만한지, 낸 답이 근거 있는지 를 스스로 묻고 거르는 방식.

일상비유 — 시험 볼 때 "이건 찾아봐야 하나?", "이 페이지 맞나?", "내 답 근거 있나?" 를 스스로 자문하는 꼼꼼한 학생.

한 줄 예 —

# AI가 스스로 "검색 필요?" 부터 판단
# 조건을 확인해서 상황에 맞는 처리 경로를 고릅니다.
if ai.decides("검색 필요?") == "Yes":
    # 질문과 가까운 문서 조각을 찾아오는 검색 단계를 실행합니다.
    docs = search(query)

이런 적 있죠? (왜 손보는가)

기본 RAG에게 회사 투자설명서를 물었다.

"이 회사 2022년 영업손실 얼마야?"

그런데 엉뚱한 페이지가 딸려 왔다.

비슷한 숫자가 적힌 2023년 단락을 가져온 것이다.

왜 이런 일이 생길까?

기본 RAG는 질문을 그대로 넣고, 한 가지 방식으로 찾고, 꺼낸 걸 그대로 쓴다.

이 세 군데가 다 허술하다.

# 아래 예제는 핵심 흐름만 짧게 보여 줍니다.
# 기본 RAG — 한 줄로 단순하게
docs = retriever.invoke(question)     # 질문 그대로 → 한 방식으로 검색
answer = llm.invoke(question + docs)  # 꺼낸 걸 그대로 사용

그래서 이 장은 이 한 줄의 칸마다 손질을 넣는다.

문서를 더 잘 자르고(청킹), 질문을 다듬고(질의 변형), 찾는 방식을 고르고(검색 알고리즘), 꺼낸 걸 다시 거른다(리랭킹).

마지막엔 AI가 흐름 전체를 스스로 점검하게도 만든다(Self-RAG).


이 장에서 딱 5가지만 (TL;DR)

  1. 청킹 — 작게 잘라 찾고, 크게 묶어 답한다(부모-자식 분할).
  2. 질의 변형 — 질문을 여러 개로 늘리거나(MultiQuery), 가상 답변으로 바꿔(HyDE) 더 잘 찾는다.
  3. 검색 알고리즘 — 단어로 찾기(BM25)·뜻으로 찾기(FAISS)·둘 섞기(앙상블) 중 고른다.
  4. 리랭킹 — 1차로 빨리 꺼낸 뒤, 2차로 꼼꼼히 다시 줄 세운다.
  5. Self-RAG — AI가 검색 여부·문서 적합성·답 근거를 스스로 점검한다.

각 칸은 따로 써도 되고, 섞어 써도 된다.


1. 청킹 — 작게 찾고 크게 답하기

망가지는 장면

300쪽 매뉴얼을 한 덩어리로 통째 저장했다.

"환불 규정"을 물으니 책 전체가 딸려 와, 정작 필요한 한 단락을 못 짚는다.

너무 크게 묶으면 검색이 뭉툭하다.

그렇다고 너무 잘게 자르면, 찾은 조각만 봐선 앞뒤 맥락을 모른다.

"이 다섯 가지"라고만 적혀 있고, 그 다섯이 무슨 얘기 안에서 나온 건지 모른다.

비유 먼저

이게 부모-자식 분할이다.

색인 한 줄(작은 자식)로 위치를 정확히 짚는다.

그리고 정작 읽는 건 그 줄이 속한 페이지 전체(큰 부모)다.

정확히 찾되, 넓게 가져온다.

비유 코드 위험
색인 한 줄로 위치 짚기 child_splitter = RecursiveCharacterTextSplitter(chunk_size=200) 자식이 너무 작으면 검색은 정확해도 따로는 뜻이 빈약
그 줄이 속한 페이지를 읽기 parent_splitter = RecursiveCharacterTextSplitter(chunk_size=1000) 부모가 너무 크면 모델에 군더더기까지 딸려 옴

한 문장 정의 — 부모-자식 분할은 작은 자식 조각으로 정확히 검색하고, 그 자식이 속한 큰 부모 조각을 답의 맥락으로 돌려주는 방식이다.

동작 순서 (3단계)

  1. 원본을 큰 부모 조각으로 나눈다 (예: 1000자).
  2. 부모를 다시 작은 자식 조각으로 나눈다 (예: 200자).
  3. 자식엔 "내 부모가 누구"라는 꼬리표(메타데이터)를 붙인다.

검색할 땐 자식으로 찾고, 꼬리표를 따라 부모를 꺼낸다.

자식은 숫자 창고(벡터DB)에, 부모는 따로 보관함(InMemoryStore)에 둔다.

예시 폭격

예시 1 — 완성 예 (그대로 따라하면 도는 코드)

# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from langchain.retrievers import ParentDocumentRetriever
# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from langchain.storage import InMemoryStore
# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from langchain_community.vectorstores import Chroma
# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from langchain_openai import OpenAIEmbeddings
# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from langchain_text_splitters import RecursiveCharacterTextSplitter

parent_splitter = RecursiveCharacterTextSplitter(chunk_size=1000)  # 부모 크게
child_splitter = RecursiveCharacterTextSplitter(chunk_size=200)    # 자식 작게

# 긴 문서를 검색하기 좋은 작은 조각으로 나눕니다.
vectorstore = Chroma(collection_name="split_parents",
                     embedding_function=OpenAIEmbeddings())  # 자식 보관
store = InMemoryStore()  # 부모 보관

# 검색이나 요약에 쓸 원본 문서를 읽어 옵니다.
retriever = ParentDocumentRetriever(
    # 문서 벡터를 저장하고 비슷한 내용을 찾을 저장소를 준비합니다.
    vectorstore=vectorstore, docstore=store,
    # 긴 문서를 검색하기 좋은 작은 조각으로 나눕니다.
    child_splitter=child_splitter, parent_splitter=parent_splitter,
)
retriever.add_documents(docs)  # 알아서 부모/자식으로 쪼개 저장

예시 2 — 부분 완성 (빈칸 채우기)

# 아래 예제는 핵심 흐름만 짧게 보여 줍니다.
# 질문을 넣어 부모 조각을 받아 보자
docs = retriever.invoke("____")   # 빈칸: 찾고 싶은 질문
print(docs[0].page_content)       # → 자식이 속한 부모(넓은 맥락) 출력

미니 시나리오 — 법률 문서 RAG를 만든다.

조항 한 줄은 정확히 찾아야 하고, 그 조항이 어느 장 안에 있는지 맥락도 필요하다.

이럴 때 부모-자식 분할이 딱 맞는다.


2. 질의 변형 — 질문을 다듬어 넣기

망가지는 장면

사용자가 "주식 투자 어떻게 시작해?"라고 대충 물었다.

이 말 그대로 검색하면, 너무 막연해서 알맹이 문서를 놓친다.

사용자는 말 안 했지만 사실 기초 지식, 자금 관리, 재무제표 보는 법까지 알고 싶은 거다.

질문이 거칠면 검색도 거칠다.

그래서 질의 변형 — 질문을 검색하기 좋게 다듬어서 넣는다.

방법이 두 가지 있다.

2.1 다중 질의 생성 — 질문을 여러 개로

비유 먼저

하나의 질문을 여러 각도로 바꿔 동시에 검색한다.

마치 한 가지 고민을 친구 셋에게 다르게 물어보고 답을 모으는 것.

비유 코드 위험
친구 셋에게 다르게 묻기 MultiQueryRetriever.from_llm(retriever=..., llm=llm) 변형 질문 만드는 데 AI 호출 비용이 든다
답을 모아 중복 빼기 retriever.invoke(question) 너무 비슷하게 변형되면 효과가 작다

한 문장 정의 — 다중 질의 생성은 AI가 원래 질문을 여러 관점의 질문으로 늘려 각각 검색하고, 결과를 합쳐 중복을 빼는 방식이다.

예시 폭격

예시 1 — 완성 예

# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from langchain.retrievers import MultiQueryRetriever
# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from langchain_openai import ChatOpenAI

# 답변을 생성할 LLM 클라이언트나 모델 설정을 준비합니다.
llm = ChatOpenAI(model="gpt-4o", temperature=0.2)
# 답변을 생성할 LLM 클라이언트나 모델 설정을 준비합니다.
retriever = MultiQueryRetriever.from_llm(
    retriever=vectorstore.as_retriever(),  # 기본 검색기
    llm=llm,                               # 질문 늘려 줄 AI
)
# 준비한 흐름에 실제 입력을 넣어 결과를 확인합니다.
docs = retriever.invoke("주식 투자를 처음 시작하려면 어떻게 해야 하나요?")

이 한 질문이 안에서 셋으로 늘어난다.

"초보가 알아야 할 기본 단계는?"

"처음 시작할 때 준비 사항은?"

"입문자를 위한 조언은?"

세 각도로 찾으니, 한 질문으로는 놓쳤을 문서까지 끌어온다.

예시 2 — 독립 적용 (스스로 떠올려 보기)

"우리 집 강아지 밥 어떻게 줘?"를 세 각도로 늘린다면?

(예: "강아지 사료 하루 몇 번?", "강아지 적정 사료량은?", "강아지 간식 주는 법은?")

2.2 가상 문서 임베딩 — HyDE

망가지는 장면

"주식 변동성이 클 때 투자 전략은?"이라고 물었다.

이 질문의 키워드는 "주식", "변동성", "전략" — 너무 일반적이다.

엉뚱한 문서까지 걸려든다.

그런데 이 질문의 은 키워드가 다르다.

"분산 투자", "달러 코스트 애버리징", "안전 자산 비중 확대" 같은 전문 용어가 들어 있다.

이 전문 용어들이 실제 책 속 표현과 더 잘 맞는다.

비유 먼저

이게 HyDE(가상 문서 임베딩)다.

질문 대신, AI가 지어낸 가상의 답변으로 검색한다.

길 물을 때 "맛집 어디?"보다, "골목 안 빨간 간판 칼국수집" 같은 구체적 묘사가 위치를 더 잘 짚는 것과 같다.

비유 코드 위험
질문 대신 구체적 묘사로 찾기 virtual_doc = chain.invoke({"query": question}) 가상 답변 만드는 데 AI 호출 비용·지연이 든다
그 묘사로 문서 검색 retriever.invoke(virtual_doc) 가상 답변이 엉터리면 검색도 엇나감

한 문장 정의 — HyDE는 질문에 대한 가상 답변을 AI로 먼저 만들고, 그 답변 텍스트로 문서를 검색하는 방식이다. 검색 대상이 질문보다 답변과 더 비슷하다는 점을 노린 것이다.

예시 폭격

예시 1 — 완성 예 (체인 3개를 잇는다)

# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from langchain_core.output_parsers import StrOutputParser
# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from langchain_core.prompts import ChatPromptTemplate
# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from langchain_openai import ChatOpenAI

# 1단계: 가상 답변 만들기
# 반복해서 쓸 처리 흐름에 이름을 붙인 함수입니다.
def make_virtual_doc():
    # 모델에게 어떤 역할과 입력을 줄지 프롬프트 틀을 만듭니다.
    prompt = ChatPromptTemplate.from_messages([
        # 이 줄은 예제 흐름을 이루는 실제 처리 단계입니다.
        ("system", "당신은 숙련된 AI입니다."),
        # 여러 값을 이름표가 붙은 구조로 묶어 전달합니다.
        ("human", "질문 '{query}'에 직접 답하는 가상 문서를 쓰세요."),
    ])
    # 답변을 생성할 LLM 클라이언트나 모델 설정을 준비합니다.
    llm = ChatOpenAI(model="gpt-4o", temperature=0.2)
    return prompt | llm | StrOutputParser()  # | 는 "연결"이라는 뜻

예시 2 — 부분 완성 (흐름 빈칸)

# 반복해서 쓸 처리 흐름에 이름을 붙인 함수입니다.
def hyde(question):
    virtual = make_virtual_doc().invoke({"query": question})  # ① 가상 답변
    docs = retriever.invoke(____)        # ② 빈칸: 무엇으로 검색? → virtual
    return final_chain.invoke({"context": docs, "question": question})  # ③ 최종 답

미니 시나리오 — 의학 논문 RAG.

환자는 "머리 아파요"라고 일상어로 묻는다.

논문엔 "편두통", "긴장형 두통" 같은 전문어가 쓰인다.

말과 문서의 표현이 크게 다르니, HyDE로 가상 답변을 만들어 검색하면 더 잘 맞는다.


3. 검색 알고리즘 — 무엇으로 비슷함을 잴까

질문과 문서가 "비슷하다"를 재는 방식은 한 가지가 아니다.

크게 두 갈래, 그리고 둘을 섞는 길이 있다.

3.1 희소 검색 — 단어가 겹치나 본다 (BM25)

망가지는 장면

"테슬라 주가 급등" 뉴스를 찾고 싶다.

가장 단순한 방법은 단어가 겹치는지 보는 것.

문서에 "테슬라", "주가"가 있으면 점수를 준다.

이게 희소 검색이다.

빠르고 단순하지만 문제가 있다.

긴 문서일수록 단어가 자연히 많이 나와 점수가 부풀고, 같은 단어가 100번 나와도 끝없이 점수가 오른다.

비유 먼저

먼저 옛 방식 TF-IDF를 보자.

  • 한 문서에 자주 나오는 단어일수록 중요(TF, 등장 빈도).
  • 모든 문서에 흔한 단어("그리고", "그러나")는 안 중요(IDF, 희귀할수록 가산점).

그런데 TF-IDF는 위의 두 함정(길이 편향·빈도 무한 증가)을 못 막는다.

이걸 손본 게 BM25다.

비유 코드 위험
흔한 단어는 깎고 희귀 단어는 가산 BM25Retriever.from_documents(docs) 단어가 안 겹치면 뜻이 비슷해도 못 찾음
긴 문서 점수 부풀기 보정 bm25_retriever.k = 2 (상위 2개) 같은 뜻 다른 단어(동의어)에 약함

한 문장 정의 — BM25는 단어 빈도와 희귀도를 쓰되, 문서 길이를 보정하고 빈도가 높아도 점수가 천천히 오르게 만든 키워드 검색 방식이다.

한 걸음 더 ▸ (지금 몰라도 됨)

한국어는 "먹다 / 먹었다 / 먹고"처럼 같은 말이 여러 모양으로 변한다. 그래서 검색 전에 형태소 분석으로 기본형("먹다")으로 통일하면 BM25가 훨씬 잘 맞는다. 코드에선 kiwipiepy 같은 한국어 분석기를 끼운다. 지금은 "한국어는 전처리 한 번 거치면 좋다" 정도만 알면 충분하다.

예시 1 — 완성 예 (한국어 전처리 포함)

# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from langchain_community.retrievers import BM25Retriever
from kiwipiepy import Kiwi  # 한국어 형태소 분석기

# `kiwi`에 중간 결과를 담아 다음 줄에서 재사용합니다.
kiwi = Kiwi()
# 반복해서 쓸 처리 흐름에 이름을 붙인 함수입니다.
def tokenize(text):
    return [t.form for t in kiwi.tokenize(text)]  # "먹었다" → 기본형 추출

# 검색이나 요약에 쓸 원본 문서를 읽어 옵니다.
bm25 = BM25Retriever.from_documents(docs, preprocess_func=tokenize)
# `bm25.k`에 중간 결과를 담아 다음 줄에서 재사용합니다.
bm25.k = 2

예시 2 — 잘못된 예 vs 올바른 예

# 잘못: 한국어에 전처리 없이 그대로 → "먹다"와 "먹었다"를 딴 단어로 봄
# 검색이나 요약에 쓸 원본 문서를 읽어 옵니다.
bm25 = BM25Retriever.from_documents(docs)

# 올바름: 형태소 분석으로 기본형 통일 → 매칭 정확도 상승
# 검색이나 요약에 쓸 원본 문서를 읽어 옵니다.
bm25 = BM25Retriever.from_documents(docs, preprocess_func=tokenize)

3.2 밀집 검색 — 뜻이 비슷한가 본다 (FAISS)

망가지는 장면

"테슬라 주가 급등"과 "전기차 수요 늘어 주식 상승"은 단어가 거의 안 겹친다.

BM25는 이 둘을 남남으로 본다.

하지만 사람 눈엔 같은 얘기다.

단어가 아니라 으로 재야 한다.

비유 먼저

글을 숫자 묶음(임베딩)으로 바꿔, 방향이 비슷하면 가깝다고 본다(0장 임베딩·코사인 유사도 참고).

이게 밀집 검색이다.

뜻을 잡으니 강력하지만, 문서 수백만 개를 일일이 비교하면 너무 느리다.

그래서 FAISS가 등장한다.

도서관이 책을 주제별 서가로 나누듯, FAISS는 벡터를 무리(클러스터) 로 묶는다.

검색할 땐 가까운 무리만 뒤져 속도를 확 줄인다.

비유 코드 위험
뜻으로 비슷한 글 찾기 FAISS.from_documents(docs, embedding) 임베딩 계산이 무거워 비용이 든다
가까운 서가만 뒤져 빠르게 vectordb.as_retriever(search_kwargs={"k": 2}) 새 분야엔 임베딩 모델 추가 조정이 필요할 수도

한 문장 정의 — 밀집 검색은 글을 임베딩으로 바꿔 뜻의 유사도로 찾는 방식이고, FAISS는 그 검색을 클러스터로 묶어 대규모에서도 빠르게 해 주는 도구다.

예시 — 부분 완성

# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from langchain_openai import OpenAIEmbeddings
# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from langchain_community.vectorstores import FAISS

# 문장이나 문서를 의미 벡터로 바꾸는 임베딩 단계를 준비합니다.
embedding = OpenAIEmbeddings(model="text-embedding-3-large")
# 검색이나 요약에 쓸 원본 문서를 읽어 옵니다.
faiss_store = FAISS.from_documents(docs, embedding)
faiss_retriever = faiss_store.as_retriever(search_kwargs={"k": ____})  # 빈칸: 몇 개 가져올지

3.3 앙상블 검색 — 둘을 섞는다

비유 먼저

희소(단어)와 밀집(뜻)은 서로 약점이 반대다.

그럼 둘 다 쓰면 된다.

이게 앙상블 검색이다.

악기 둘이 같이 연주해 더 풍부한 소리를 내는 합주처럼, 두 검색 결과를 가중치로 섞는다.

비유 코드 위험
단어+뜻 합주 EnsembleRetriever(retrievers=[bm25, faiss], weights=[0.5, 0.5]) 비중을 잘못 주면 한쪽 장점이 죽음
도메인 따라 비중 조절 weights=[0.7, 0.3] (단어 중시) 두 검색을 다 돌려 약간 느려짐

한 문장 정의 — 앙상블 검색은 단어 기반(BM25)과 뜻 기반(FAISS)을 가중치로 합쳐, 정확한 용어 매칭과 의미 검색을 동시에 얻는 방식이다.

예시 — 올바른 적용

# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from langchain.retrievers import EnsembleRetriever
# 법률 문서면 정확한 용어가 중요 → 단어(BM25) 비중을 높임
# 문서 벡터를 저장하고 비슷한 내용을 찾을 저장소를 준비합니다.
ensemble = EnsembleRetriever(retrievers=[bm25, faiss], weights=[0.7, 0.3])
# 준비한 흐름에 실제 입력을 넣어 결과를 확인합니다.
docs = ensemble.invoke("이 회사 주식 총 발행량이 얼마야?")

미니 시나리오 — 일반 고객 챗봇이면 말투가 제각각이라 검색이 중요하니 FAISS 비중을 높인다.

법률·기술 문서면 정확한 용어가 생명이니 BM25 비중을 높인다.


4. 리랭킹 — 꺼낸 걸 다시 줄 세우기

망가지는 장면

BM25나 FAISS로 빠르게 4개를 꺼냈다.

빠르긴 한데, 4개 중 2개는 사실 결이 다른 문서다.

"2022년 영업손실" 물었는데 "2023년" 단락도 섞여 있다.

1차 검색은 빠른 대신 대충이라 그렇다.

비유 먼저

그래서 리랭킹 — 1차로 추린 뒤, 더 꼼꼼한 채점기로 다시 줄 세운다.

서류 100장을 1차로 추리고(빠름), 2차에서 한 장씩 정독해 알짜만 남기는 것(정확).

채점기는 두 종류가 있다.

비유 코드 위험
똑똑한 면접관이 직접 평가 chain.invoke({"query": q, "doc": d}) (LLM 리랭킹) 문서마다 AI 호출 → 느리고 비쌈
빠른 전용 채점기로 평가 crossencoder.predict(pairs) (크로스 인코더) 정확도는 면접관보다 살짝 아래

4.1 LLM 기반 리랭킹

GPT 같은 똑똑한 모델에게 "이 문서가 질문과 얼마나 관련 있어? 1~10점 줘"라고 직접 묻는다.

추론을 잘해 정확하고, 점수 이유까지 설명해 준다.

대신 문서마다 AI를 부르니 느리고 비싸다.

한 문장 정의 — LLM 기반 리랭킹은 똑똑한 모델에게 문서별 관련성 점수를 직접 매기게 하는 방식으로, 정확도가 높지만 비용·속도가 부담이다.

예시 — 완성 예 (점수로 다시 정렬)

# 반복해서 쓸 처리 흐름에 이름을 붙인 함수입니다.
def rerank(query, docs, top_n=2):
    # 검색·답변 품질을 점수나 기준으로 비교합니다.
    scored = []
    # 목록이나 결과 묶음을 하나씩 꺼내 같은 처리를 반복합니다.
    for doc in docs:
        score = chain.invoke({"query": query, "doc": doc.page_content})  # 1~10점
        # 검색·답변 품질을 점수나 기준으로 비교합니다.
        scored.append((doc, float(score["relevance_score"])))
    scored.sort(key=lambda x: x[1], reverse=True)  # 점수 높은 순
    return [doc for doc, _ in scored[:top_n]]       # 상위 2개만

4.2 크로스 인코더 기반 리랭킹

이번엔 BERT 같은 전용 채점기를 쓴다.

질문과 문서를 한 덩어리로 붙여 넣고, "둘이 얼마나 관련 있나"를 점수 하나로 뱉는다.

LLM보다 가볍고 빨라, 비용이 싸다.

대신 점수 이유는 설명 못 하고, 정확도는 살짝 아래일 수 있다.

한 문장 정의 — 크로스 인코더 리랭킹은 질문과 문서를 함께 넣어 관련성 점수를 직접 내는 가벼운 전용 모델 방식으로, 속도·비용이 유리하다.

예시 — 부분 완성

# 필요한 라이브러리나 클래스를 가져와서 아래 예제에서 쓸 준비를 합니다.
from sentence_transformers import CrossEncoder
# `crossencoder`에 중간 결과를 담아 다음 줄에서 재사용합니다.
crossencoder = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-12-v2")

pairs = [[query, doc.page_content] for doc in initial_docs]  # 질문+문서 쌍
scores = crossencoder.predict(____)   # 빈칸: 무엇을 채점? → pairs

한 걸음 더 ▸ (지금 몰라도 됨)

밀집 검색에 쓰는 방식은 바이 인코더 다. 질문과 문서를 따로따로 숫자로 바꿔 비교한다. 빠르다. 크로스 인코더는 둘을 붙여서 본다. 더 정확하지만, 모든 쌍을 다 따지면 양이 폭발해 느리다. 그래서 실전 황금 패턴은 바이 인코더로 빨리 추리고 → 크로스 인코더로 정밀 재평가 하는 2단이다. 지금은 "빨리 추리고 꼼꼼히 거른다" 한 줄만 가져가면 된다.

미니 시나리오 — 실시간 고객 챗봇은 답이 빨라야 한다.

문서마다 GPT를 부르는 LLM 리랭킹은 너무 느리다.

가벼운 크로스 인코더가 현실적이다.


5. Self-RAG — AI가 스스로 점검하기

망가지는 장면

지금까지의 손질은 다 좋다.

그런데 기본 RAG엔 구조적인 허점이 남아 있다.

검색이 필요 없는 질문에도 무조건 문서를 끌어온다.

관련 없는 문서가 와도 그대로 쓴다.

낸 답이 문서와 맞는지 확인 안 한다.

비유 먼저

이걸 AI가 스스로 점검하게 만든 게 Self-RAG다.

꼼꼼한 학생이 시험 볼 때 스스로 자문하는 것과 같다.

"이건 찾아봐야 하나?" → "이 페이지 맞나?" → "내 답 근거 있나?"

AI는 각 단계에서 특수 토큰(짧은 신호말)을 뱉어 흐름을 제어한다.

비유 코드 (신호) 위험
"찾아봐야 하나?" 자문 Retrieve = Yes / No 단계마다 AI 호출 → 느리고 비쌈
"이 문서 맞나?" 걸러내기 ISREL = Relevant / Irrelevant 점검 자체가 틀리면 좋은 문서를 버림
"내 답 근거 있나?" 점검 ISSUP (지원) · ISUSE (유용성 1~5) 여러 답 생성 → 처리 시간 증가

한 문장 정의 — Self-RAG는 AI가 검색 필요 여부, 문서 관련성, 답의 근거·유용성을 스스로 신호로 표시하며 점검하는 방식이다.

동작 순서 (3단계)

  1. 검색?Retrieve=Yes면 문서를 찾고, No면 그냥 답한다.
  2. 관련성 — 찾은 문서마다 ISREL로 Relevant/Irrelevant 표시. 관련 없는 건 버린다.
  3. 답 점검ISSUP로 "답이 문서에 근거하나"(Fully/Partially/No), ISUSE로 "얼마나 유용한가"(1~5점).

마지막에 가장 근거 있고 유용한 답을 고른다.

예시 폭격

예시 1 — 완성 예 (검색 여부부터 자문)

# 관련 데이터와 동작을 하나의 이름 아래 묶는 클래스입니다.
class RetrievalResponse(BaseModel):
    Reasoning: str   # 왜 검색이 필요한지(또는 아닌지) 이유
    Retrieve: Literal["Yes", "No"]   # 검색 필요 여부 신호

# 질문을 넣으면 AI가 "Yes/No"로 답함
# 준비한 흐름에 실제 입력을 넣어 결과를 확인합니다.
decision = retrieval_chain.invoke({"query": query}).Retrieve
# 조건을 확인해서 상황에 맞는 처리 경로를 고릅니다.
if decision != "Yes":
    # 준비한 흐름에 실제 입력을 넣어 결과를 확인합니다.
    answer = generation_chain.invoke({"query": query, "context": "없음"})

예시 2 — 부분 완성 (관련 문서만 남기기)

# `relevant`에 중간 결과를 담아 다음 줄에서 재사용합니다.
relevant = [
    # 이 줄은 예제 흐름을 이루는 실제 처리 단계입니다.
    ctx for ctx in contexts
    # 준비한 흐름에 실제 입력을 넣어 결과를 확인합니다.
    if relevance_chain.invoke({"query": q, "context": ctx}).ISREL == "____"
]  # 빈칸: 어떤 값만 남길까? → "Relevant"

미니 시나리오 — 의료·법률·금융처럼 틀리면 큰일 나는 분야.

답의 근거를 한 번 더 자가 검증하는 Self-RAG가 신뢰를 높인다.

대신 단계마다 AI를 부르니, 빠른 응답이 생명인 서비스엔 안 맞는다.


단순 규칙 (고를 때 이대로)

  • 긴 문서에서 맥락도 중요 → 부모-자식 분할.
  • 질문이 막연·다각도 → MultiQuery. 질문과 문서 표현이 크게 다름 → HyDE.
  • 정확한 단어 매칭 → BM25(한국어는 형태소 전처리). 뜻 검색 → FAISS. 둘 다 → 앙상블.
  • 1차 검색이 대충이라 거르고 싶다 → 리랭킹. 정확도 최우선 → LLM, 속도·비용 우선 → 크로스 인코더.
  • 틀리면 큰일 나는 분야 → Self-RAG(단, 느림·비쌈 감수).
  • 이 칸들은 섞어 써도 된다. 예: 부모-자식 + 앙상블 + 크로스 인코더 리랭킹.

정리 (핵심 3줄)

기본 RAG의 한 줄 흐름은 칸마다 더 똑똑하게 손볼 수 있다.

청킹(작게 찾고 크게 답)·질의 변형(질문 다듬기)·검색 알고리즘(단어·뜻·섞기)·리랭킹(다시 줄 세우기) — 네 칸을 강화한다.

마지막엔 Self-RAG로 AI가 흐름 전체를 스스로 점검해, 관련성과 신뢰성을 끌어올린다.


더 해보기

모델·라이브러리 버전은 수시로 바뀐다. gpt-4o·text-embedding-3-large 표기는 2025년 기준 예시이니, 실제로 쓸 땐 공식 문서(python.langchain.com)를 확인하라. 책의 개념·흐름은 그대로 유효하다. (검증 2026-05-21)



연습문제

  1. 설명. 검색을 똑똑하게 만드는 법의 핵심을 처음 듣는 사람에게 한 문장으로 설명하라.
  2. 구분. 두 개념(리트리버, 리랭커)을 실제 예시 하나로 구분하라.
  3. 적용. 내 프로젝트나 학습 노트에서 이 장의 개념을 적용해 작게 개선할 지점을 하나 고르라.

부록 A. 쉬운 용어 사전

용어 아주 쉬운 뜻 이 장에서 나온 위치
리트리버 질문과 가까운 문서 후보를 찾아오는 검색 담당 부품. 부록 B와 본문 예시
리랭커 처음 찾은 후보를 다시 읽고 더 좋은 순서로 줄 세우는 부품. 부록 B와 본문 예시
질의 변형 검색이 잘 되도록 사용자의 질문을 바꾸거나 여러 형태로 늘리는 일. 부록 B와 본문 예시
검색 알고리즘 무엇을 기준으로 비슷한 자료를 찾을지 정하는 방법. 부록 B와 본문 예시

부록 B. 헷갈리는 개념 비교표

A B 구분 포인트
리트리버 리랭커 리트리버는 후보를 찾고, 리랭커는 후보 순서를 다시 매긴다.
질의 변형 검색 알고리즘 앞은 질문을 고치고, 뒤는 비슷함을 재는 방식을 고른다.

부록 C. 더 읽을 자료

  • 이 장의 더 해보기 섹션 — 이미 모아 둔 공식 문서나 실습 링크가 있으면 여기서 먼저 확인한다.
  • 같은 책의 0장 한눈에 보기 — 용어가 막히면 0장의 용어집과 개념 척추로 돌아간다.
  • 원본 딥다이브판 같은 장 — 입문판을 읽고 큰 흐름이 잡힌 뒤 세부 논리를 더 깊게 확인한다.
  • 이 장의 flashcards.json — 읽은 직후 질문만 보고 답을 떠올리는 회상 연습에 쓴다.

부록 D. 연습문제 풀이

  1. 설명 예시. 검색을 똑똑하게 만드는 법는 RAG에서 자료를 더 잘 찾고, 근거를 더 안전하게 붙이고, 답변 흐름을 더 다루기 쉽게 만드는 방법을 보는 장이다. 중요한 것은 용어를 외우는 것이 아니라, 이 개념이 어떤 입력·부품·결정에 영향을 주는지 말로 풀어 보는 것이다.
  2. 구분 예시. 두 개념(리트리버, 리랭커)의 차이는 이렇게 잡으면 된다. 리트리버는 후보를 찾고, 리랭커는 후보 순서를 다시 매긴다. 실제 사례를 볼 때는 목적, 입력, 실패했을 때의 증상을 따로 적어 보면 헷갈리지 않는다.
  3. 적용 예시. 가장 작은 개선부터 고른다. 예를 들어 이름을 더 분명히 하거나, 평가 기준을 한 줄 추가하거나, 직접 알 필요 없는 내부 정보를 감추는 식으로 시작한다. 한 번에 크게 갈아엎는 것보다 작은 변경 하나를 확인하며 진행하는 쪽이 입문 단계에 맞다.

다음 장 예고

다음 장에서는 이렇게 고도화한 RAG가 실제로 잘 답하는지 어떻게 점수로 재는지 본다.

지금은 4장의 다섯 칸(청킹·질의 변형·검색·리랭킹·Self-RAG)만 머리에 있으면 충분하다.

난이도
에피소드
질문
카드를 로딩 중...
답변

클릭하거나 Space를 눌러 뒤집기

0 / 0
학습 진도 0%
이동   Space 뒤집기   R 셔플